Reading query and form fields in API calls
-
What you'll see
A developer — or more often an AI code-generation tool — writes an API endpoint as if it were running under Express or Node.js:
// #/API/hello.js — WRONG, all of it export default function (req, res) { var name = req.query.name; // TypeError — req is null var body = JSON.parse(request.body); // request has no body property res.json({ message: "Hello " + name }); // there is no res — return the value instead }None of this works. There is no
req/respair in Docly. A parameter namedreqis looked up as an input field called "req" — which doesn't exist — so it isnull, and the first property access throws. The globalrequestobject exists, but it holds request metadata (URL,Jwt, file/folder info) — not the query string and not the body.A quieter version of the same surprise: the parameters bind correctly, the endpoint works in testing, and then it throws for one particular user.
// POST body: tags=news (one checkbox ticked) export default (tags) => { return tags.map(t => t.toUpperCase()); // throws — a string has no map() };With two boxes ticked the same code works, because
tagsis then an array. With one ticked it is a string. See below. -
What's actually happening
When a request hits an
#/API/endpoint, Docly prepares the input before your function runs:- query (global) — every URL query-string parameter as a key/value map. All values are strings, always.
?page=2givesquery.page === "2", so parse numbers yourself. - form (global) — the request body:
- For form posts (
application/x-www-form-urlencodedormultipart/form-data), a field sent once is a string, and a field sent more than once is an array of strings — the same types a JSON body gives. An empty field is"", which is falsy, soif (!title)catches it. - A field may legitimately appear more than once —
a=1&a=2, and HTML forms do it routinely with checkboxes and multi-selects. So the type of such a field depends on what the user selected: one ticked box gives a string, two give an array, and none leaves the field out entirely. Normalise it when you need a list (see below). - For JSON posts (
Content-Type: application/json), Docly deserializes the entire body intoform. Values keep their JSON types — nested objects, arrays, numbers and booleans come through as-is.
- For form posts (
- Auto-bound parameters — if the file exports a default (or anonymous) function, each parameter is bound by name from the input:
formis checked first, thenquery.export default function (email, page) { ... }receivesform.email/query.emailandform.page/query.pageautomatically — whichever is present.
The same endpoint serves both GET and POST: on a GET there is simply no body, so
formis empty and everything arrives inquery.Two details worth knowing:
- If both
formandquerycontain the same key, form wins in parameter binding. - A JSON body must be an object at the top level (
{ ... }). Posting a bare array or scalar leavesformnull — wrap the payload:{ "items": [...] }.
- query (global) — every URL query-string parameter as a key/value map. All values are strings, always.
-
What to do
Pick the simplest mechanism that fits, and never parse the body yourself.
Do — named parameters for simple endpoints (works for both GET and POST):
// #/API/greet.js // GET /API/greet?name=Ada → name = "Ada" (from query) // POST /API/greet {"name": "Ada"} → name = "Ada" (from JSON body) export default function (name) { return { message: "Hello, " + (name || "stranger") + "!" }; }Do — the query global for URL parameters (remember: values are strings):
// #/API/articles.js — GET /API/articles?page=2&tag=news export default function () { var page = parseInt(query.page || "1"); var tag = query.tag || null; return docly.getFiles("~/articles") .filter(a => !tag || a.Tag == tag) .slice((page - 1) * 20, page * 20); }Do — the form global for structured JSON bodies:
// #/API/orders.js // POST /API/orders with Content-Type: application/json // body: { "customer": { "name": "Ada", "email": "ada@example.com" }, "lines": [ ... ] } export default function () { var customer = form.customer; // nested object, types preserved var lines = form.lines || []; // array comes through as an array if (!customer || !isEmail(customer.email)) throw "Invalid customer"; return docly.saveFile("~/orders/" + guid() + ".json", { Content: JSON.stringify(form) }); }Do — call it from the browser with ~/ paths:
fetch('~/API/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer: { name: 'Ada', email: 'ada@example.com' }, lines: [] }) }).then(r => r.json()).then(result => console.log(result));Do — read a form field, and normalise the ones that can repeat:
// POST application/x-www-form-urlencoded // title=Hello&tags=news&tags=sport export default function (title, tags) { if (!title) throw "Title is required"; // one value: a string, "" when left empty title = title.trim(); // A checkbox group: one ticked box is a string, two are an array, none is no field at all. var list = Array.isArray(tags) ? Array.from(tags) : (tags ? [tags] : []); return { title: title, tags: list, count: list.length }; }Turn a list into text yourself with
list.join(", "). Writing"Tags: " + tagsgivesTags: ["news","sport"]when there are two values.Older code. Earlier Docly versions passed every form field as a .NET
StringValuescollection, and this article used to recommendArray.from(form.title)[0]andform.title.Count. On a single value both now give the wrong answer without an error:Array.fromsplits a string into characters ("H"), and.Countis the length of the text. Replace them:String(form.title)for one value, andArray.isArray(v) ? Array.from(v) : [v]for a list. Both work with the old and the new behaviour.Don't:
export default function (req) { ... } // no Express-style request argument exists var body = JSON.parse(request.body); // request has no body — JSON is already in form var name = request.query.name; // request has no query — use the query global form.tags.map(...); // throws when only one box is ticked — normalise first Array.from(form.title)[0]; // "H" — Array.from splits a string into characters form.title.Count; // the length of the text, not the number of valuesFile uploads are the one exception: files posted as
multipart/form-datado not appear inform. Usedocly.getUploads()to list them anddocly.getUploadBase64(fieldName)to read one. Ordinary text fields in the same multipart post still arrive inform.queryandformexist only in#/API/scripts. Hash pages (.hash) are rendered once and cached, so they cannot read per-request input at all — route dynamic input through an API endpoint instead.
Last reviewed 23 Oct 2026